sdk: give v1 byte fields the type the proto declares - #1124
Merged
Conversation
kvinwang
force-pushed
the
feat/sdk-v1-bytes
branch
from
August 25, 2026 02:44
d5557a4 to
5f829bc
Compare
kvinwang
force-pushed
the
feat/sdk-v1-bytes
branch
from
August 25, 2026 03:14
0566c7e to
b393311
Compare
kvinwang
force-pushed
the
feat/sdk-v1-bytes
branch
from
August 25, 2026 03:16
b393311 to
fe63cb1
Compare
Where `agent_rpc_v1.proto` says `bytes`, three of the four SDKs handed the caller a hex string. No two of them drew the line in the same place either: Go decoded all eleven byte fields, JS decoded three, Rust and Python decoded none and shipped a partial set of `decode_*` helpers -- Rust was missing three, Python six. The type is not cosmetic here. `docs/guest-api-v1.md` has to say of the chain claim that `public_key` is "the raw derived public key ... not a hex string", and three separate documents have to repeat "hash the decoded bytes, not the string as returned" for `evidence`. Those sentences exist because the types did not say it: pass a hex `public_key` straight into the claim builder and you build the claim over 66 ASCII characters instead of 33 bytes -- no type error, no exception, just a chain that never verifies. In Go that mistake is unspellable. All eleven fields are now the language's own byte type -- `Vec<u8>`, `bytes`, `Uint8Array` -- including the `InfoResponse` identity fields, and every v1 `decode_*` helper is gone. Rust's `AttestConfig.report_data` goes with them: it is a public request type that took hex while `attest()` took bytes, the only place a caller had to encode by hand to use the builder directly. The wire is unchanged. JSON still carries lowercase hex, moved into the serialization layer: `hex::serde` in Rust with a small module for the `repeated bytes` chain, one annotated pydantic alias in Python, and decoding at the client boundary in JS, which had no serialization layer to put it in. v0 keeps its hex strings and its decoders, untouched. That surface mirrors the released 0.5.x SDK so a 0.5.x program keeps working by changing only a class name; retyping every byte field would break exactly that promise. Two things surfaced on the way. `cargo test` in `sdk/rust` never ran the types crate -- the workspace root is itself a package, so the default member was `dstack-sdk` alone and the new unit tests would have been dead in CI; `run-tests.sh` now passes `--workspace`. And the JS wire types were derived from the public ones via `Omit<..., 'decodeEvidence'>`, which silently becomes the decoded type once that method is deleted, typing `send_rpc_request` as returning `Uint8Array` from JSON. Both wire shapes are written out now.
Three defects an adversarial pass found in the conversion itself.
Rust's `report_data` kept `#[builder(into)]`, and both `&str` and
`String` implement `Into<Vec<u8>>`. So `.report_data("00ff")` still
compiled and attested the four ASCII bytes of that string -- exactly the
failure the change was made to eliminate, on the one field a caller
sends rather than receives. It is now a `ReportData` newtype that
converts from a `Vec<u8>`, an array or a slice and from nothing else, so
the builder keeps the coercion that made those three ergonomic while a
string becomes a type error. A `compile_fail` doctest keeps it one.
JavaScript decoded with `Buffer.from(value, 'hex')`, which stops at the
first pair it cannot parse and returns the prefix, silently: a corrupted
`app_id` came back as a short `Uint8Array`, `signature_chain:
["aabb","qq"]` came back one link short, and an absent required field
came back as an empty array that is *truthy*, so `if (!info.app_id)`
guards became dead code. Rust, Python and Go all reject these. It now
throws and names the field, and absence of a required field is an error
rather than empty bytes -- `os_image_hash` and `mr_aggregated` still
read as empty, so a degraded `Info` stays parseable.
Python accepted hex with embedded whitespace, which `bytes.fromhex`
skips, while raising a message that said it did not; and it rejected a
response that omits `os_image_hash` or `mr_aggregated`, where Rust has
`#[serde(default)]`. Both aligned.
None of this was covered: JS and Python had zero negative tests for any
v1 byte field, which is why a green suite proved nothing here. Added,
and mutation-checked -- four of the five new JS tests fail against the
old lenient decoder.
Also corrects the CHANGELOG, which said four of the eleven fields had no
Rust decoder where the number is three, and which claimed "the wire is
unchanged" without saying that only the JSON wire is: borsh writes a
`Vec<u8>` as length-prefixed bytes where it wrote a hex `String`, so a
0.5.x blob deserializes without error into the wrong content. Three
comments called `os_image_hash` and `mr_aggregated` `optional` on the
wire; the proto declares them plain `bytes`, and `not_before` and
`not_after` are the only `optional` fields it has. Tolerating their
absence is a client-side choice, and now says so.
kvinwang
force-pushed
the
feat/sdk-v1-bytes
branch
from
August 25, 2026 03:36
fe63cb1 to
b656c7c
Compare
Differential testing against the other three SDKs -- 194 identical JSON
bodies through four real clients -- found the JavaScript decoder still
producing a silently wrong value, in the helper added to stop exactly
that. `RegExp.test` stringifies its argument, so `app_id: ["00112233"]`
passes the hex check, and `Buffer.from` then ignores its `'hex'`
argument for a non-string input and coerces the element as an octet:
`Number('00112233') & 0xff`, one attacker-chosen byte, no error. Rust,
Python and Go all reject the same body. Decoding now requires a string
before it looks at the characters.
The same run found four more shapes where JavaScript was the outlier,
all of them absence or `null` rather than bad hex, so all reachable only
from a corrupted or non-dstack server -- a conforming agent emits every
field, always lowercase hex, never `null`:
- `signature_chain` was dereferenced bare, so `null` or an absent field
raised `TypeError: Cannot read properties of null (reading 'map')`,
which names no field and reads as an SDK bug rather than a bad
response. Repeated fields now go through one checked accessor.
- `bundles ?? []` read an absent `bundles` as "this host has no GPUs",
which is a different claim from "the response was malformed". Absence
is an error there and empty for `boottime_gpu_evidence`, which is the
distinction the proto draws.
- `os_image_hash: null` was read as empty bytes. Only an absent key is
the empty default now, which is what `#[serde(default)]` means in Rust
and what the pydantic default does in Python; an explicit `null` is a
malformed value in all three.
- Responses were built by spreading the raw JSON, so a field the agent
omitted came back `undefined` from an interface that declares it
`string` -- `info().app_compose.length` threw on a value the compiler
called safe. All four responses are now built field by field.
Rust gets `#[serde(default)]` on `InfoResponse::app_name`, the one
string in that struct without it, where absence was an error in Rust and
the empty string in the other three.
Also: `docs/confidential-ai.md` printed `info.compose_hash` for a data
provider to compare against the compose file they reviewed, which is a
bytes repr since the retyping; and the CHANGELOG's borsh warning pointed
at 0.5.x, which never shipped these types -- the window is between 0.6
prereleases.
Every new test was mutation-checked: each fails against the code it
fixes.
Rust and Go always took bytes here. Python and JavaScript also accepted a
string and UTF-8 encoded it, which reads as a convenience right up until
the string is a hex digest:
client.attest("deadbeef") # eight ASCII characters, not four bytes
No error, no exception, just a quote over a value the caller did not
mean. `attest_gpu` was worse: SPDM fixes the nonce at 32 bytes, so a
32-character string passed the length check on its way to attesting the
wrong nonce, and the check that was supposed to catch a bad nonce was
the reason this one got through.
This is the request-direction half of typing the v1 `bytes` fields. The
response half made a hex string unspellable where bytes were meant; the
same argument applies to the two fields a caller sends, and Rust's
`AttestConfig` already got it in the form of the `ReportData` newtype.
Both SDKs now raise, and name the two ways out -- encode the text or
decode the hex -- because only the caller knows which was meant.
Python routes both parameters through one `_require_bytes`, so the two
methods cannot drift: `attest_gpu` previously folded the type check into
its length check and answered a non-bytes nonce with a complaint about
its length.
**Breaking for a v1 caller passing a string.** v1 has not shipped, and
the v0 clients keep the old signature untouched -- a 0.5.x program
migrating by class name alone is unaffected, and that surface is frozen
precisely so this kind of change cannot reach it.
The JavaScript check is at runtime rather than in the parameter type
alone: a plain-JS caller, or TypeScript holding an `any` out of
`JSON.parse`, reaches the method with a string and no compiler in the
way.
Both new tests were mutation-checked against the lenient behaviour they
replace.
…t some Differential testing -- 194 identical JSON bodies through four real clients, one fake agent per body -- put the four SDKs at 143 agreements and 51 divergences. Every divergence was absence, `null`, or JSON type confusion; none was bad hex, and none is reachable from a conforming agent, which emits every field, always lowercase hex, never `null`. All of them are reachable from a compromised or non-dstack server, which is the threat model private keys, signature chain links and application identity already take seriously. Go was the outlier in 39 of them, and its failure mode was the worst available: decoding into a `string` turned both an absent key and a JSON null into "", "" hex-decodes to empty bytes, and the error was nil. An error body arriving with a 200 -- the one shape the transport's status check does not cover -- therefore returned a zero-length private key, an empty app_id, and `err == nil`. Required fields now decode through a pointer so absence is visible, `rpcError` surfaces the agent's own words, and the fields whose absence is genuinely the empty default read the raw JSON, because a pointer cannot tell an omission from a null and only one of those is an answer. JavaScript's bundle decoding kept the same gap the response fields had: `vendor` and `format` came through a spread of the raw JSON, so a bundle missing one yielded `undefined` from an interface declaring `string`. Those two are what a caller switches on to pick a verifier, so the evidence was not degraded, it was routed to no verifier at all -- quietly, since `undefined` matches no `case`. Rust and Python needed nothing here; they already refused every one of these bodies. The remaining seven divergences after the Go rewrite were Go accepting `null` for `Info`'s plain string fields, fixed the same way. The 194 bodies now agree 194/194.
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last SDK item on the 0.6 prerelease checklist (#1094): "Represent protobuf byte fields as bytes in Rust, not hex strings."
Stacked on #1122 → #1120. Review those first; this diff is only the commit on top.
The state it found
An audit of the v1 surface's eleven
bytesfields found no two SDKs drawing the line in the same place:AttestResponse.attestationwas the worst cell: Go returned[]byte, Rust and Python each shipped adecode_attestation(), and JS gave you a string and nothing at all.Why the type, not just the helper
docs/guest-api-v1.mdhas to say of the chain claim thatpublic_keyis "the raw derived public key ... not a hex string", and its verification steps say to rebuild the claim from raw bytes. Three separate documents have to repeat "hash the decoded bytes, not the JSON string as returned" forevidence.Those sentences exist because the types did not say it. Pass a hex
public_keystraight into the claim builder and you build the claim over 66 ASCII characters instead of 33 bytes — no type error, no exception, just a chain that never verifies.sha256(bundle.evidence)on a hex string compiles and returns a confidently wrong digest. In Go both mistakes are unspellable, which is the property the other three now get.What changed
All eleven fields become the language's own byte type —
Vec<u8>,bytes,Uint8Array— including theInfoResponseidentity fields, and every v1decode_*helper is deleted. Rust'sAttestConfig.report_datagoes with them: it is apubrequest type that took hex whileattest()took bytes, the only place a caller using the builder directly had to encode by hand.The wire does not change. JSON still carries lowercase hex; the encoding moves into the serialization layer —
#[serde(with = "hex::serde")]in Rust with a small module for therepeated byteschain, oneAnnotatedpydantic alias in Python, and decoding at the client boundary in JS, which has no serialization layer to put it in.v0 keeps its hex strings and its decoders, untouched. That surface mirrors the released 0.5.x SDK so a 0.5.x program keeps working by changing only a class name. Retyping every byte field there would break exactly the promise the v0 client exists to make.
Two things this surfaced
cargo testinsdk/rustnever ran the types crate. The workspace root is itself a package, so the default member isdstack-sdkalone — the new unit tests would have been dead in CI.run-tests.shnow passes--workspace.Omit<GpuEvidenceBundleV1, 'decodeEvidence'>. Once that method is deleted theOmitsilently becomes the decoded type, which would have typedsend_rpc_requestas returning aUint8Arraystraight out of JSON. Both wire shapes are written out explicitly now.Verification
./sdk/run-tests.shexit 0 — Rust 51 tests plus both examples, Go clean on both packages, Python 160 passed, JS 141 passed. Plus the threeno_std/wasm targets CI checks (wasm32-unknown-unknown,no_std_test --no-default-features,thumbv6m-none-eabi),clippy --workspace --all-targets -D warnings,gofmt/go vet,tsc --noEmit, andpdm run check.One note on
hex: itsserdefeature is the implicit feature of an optional dependency, so it does not appear in the manifest's[features]. Confirmed from the vendored source thathex::serdeneeds onlycoreplusalloc, which this crate already has — and then confirmed empirically on all three bare-metal targets.